5.15. Luau
Luau
Luau — это диалект языка программирования Lua, разработанный компанией Roblox Corporation как усовершенствованная, промышленно-ориентированная версия Lua 5.1, адаптированная под нужды платформы Roblox. Хотя синтаксически и семантически Luau остаётся совместимым с Lua 5.1 на уровне базовой лексики и исполнения, он вносит существенные изменения в модель типизации, инструментарий времени разработки и производительность. Эти изменения отвечают на вызовы, с которыми сталкиваются разработчики крупных интерактивных миров: необходимость в статической проверке ошибок, поддержка командной работы, масштабируемость кодовой базы и гарантии безопасности при разделении логики между клиентом и сервером.
Важно понимать, что Luau — это не просто «Lua с типами». Это архитектурное расширение, затрагивающее всю цепочку разработки: от редактирования кода в среде Roblox Studio до исполнения в виртуальной машине на клиенте и сервере. В отличие от многих других форков Lua (например, MoonScript, Fennel или Typed Lua, последний из которых оказал влияние на ранние версии Luau), Luau развивается не как независимый проект с открытым исходным кодом, а как внутренняя технология, глубоко интегрированная в экосистему Roblox, и одновременно — как open-source реализация: с 2020 года Roblox Corporation публикует исходный код компилятора и статического анализатора Luau под лицензией MIT на GitHub. Это позволяет изучать язык не только как инструмент для создания игр, но и как интересный кейс эволюции динамически типизированного языка в сторону гибридной типизации без ущерба для производительности и доступности.
Ниже мы подробно рассмотрим как лингвистические, так и системные особенности Luau, а также их проекцию на архитектуру приложений в среде Roblox.
1. Происхождение и философия Luau
Lua 5.1, выпущенный в 2006 году, остаётся одним из самых компактных и гибких скриптовых языков, идеально подходящим для встраивания в приложения. Его минимализм, наличие корутин, таблиц как универсальной структуры данных и гибкой метапрограммирования через метатаблицы сделали его популярным в игровой индустрии: от World of Warcraft до Factorio. Однако по мере роста сложности проектов, разрабатываемых на платформе Roblox (которая к середине 2010-х перешла от конструктора мини-игр к полноценной платформе для создания persistent-вселенных), выяснилось, что чисто динамическая природа Lua создаёт серьёзные проблемы:
- отсутствие возможности выявлять опечатки в именах полей или вызовах методов на этапе написания кода;
- невозможность построения точных контрактов интерфейсов между модулями;
- сложность поддержки кода в командах из нескольких разработчиков;
- отсутствие инструментов автодополнения и рефакторинга, сравнимых с теми, что доступны для TypeScript или C#.
Первоначально Roblox использовал Lua 5.1 с некоторыми внутренними расширениями, включая собственную библиотеку для работы с объектной моделью (Roblox Instance API). Однако с 2019 года началась постепенная замена интерпретатора Lua на собственную виртуальную машину, а в 2020 — появление первого прототипа статической типизации. В 2021 году был представлен Luau Type Checker и Luau Language Server, а в 2022 — официальное объявление о переходе всей платформы на Luau.
Ключевой философский принцип Luau — ненавязчивая статика (gradual typing). Это означает:
- код без аннотаций типов остаётся валидным и исполняется точно так же, как в Lua 5.1;
- аннотации не влияют на семантику во время выполнения — они обрабатываются только на этапе статического анализа;
- разработчик может постепенно добавлять типы, начиная с критически важных модулей, не переписывая всю кодовую базу.
Такой подход позволяет сохранить низкий порог входа для новичков (в том числе детей и подростков, составляющих значительную долю аудитории Roblox), одновременно предоставляя профессионалам инструменты для построения надёжных систем.
2. Статическая типизация: от динамики к строгости без потерь
2.1. Синтаксис аннотаций типов
Основной способ указания типа — аннотация после двоеточия, как в TypeScript:
local playerName: string = "Guest"
local health: number = 100
local isAlive: boolean = true
Аннотации могут быть указаны:
- для локальных переменных;
- для параметров функций;
- для возвращаемых значений (в том числе множественных);
- для полей таблиц (включая классы, построенные на основе метатаблиц).
Пример функции с полной типизацией:
type Vector3 = { x: number, y: number, z: number }
function calculateDistance(a: Vector3, b: Vector3): number
local dx = a.x - b.x
local dy = a.y - b.y
local dz = a.z - b.z
return math.sqrt(dx*dx + dy*dy + dz*dz)
end
Обратите внимание: type — это не ключевое слово времени выполнения, а директива для анализатора. Она не создаёт объектов, не генерирует проверок в рантайме. Это чисто статическая конструкция.
2.2. Система типов Luau
Система типов Luau включает:
- Примитивные типы:
nil,boolean,number,string,thread,userdata(представлен какInstanceи его подтипы — см. ниже). - Составные типы:
table<K, V>— параметризованный тип таблицы;{ field1: T1, field2: T2, ... }— структурные типы («анонимные интерфейсы»);(T1, T2) -> T3— тип функции;
- Специальные типы:
any— «выключатель» типизации, эквивалентunknownв TypeScript по строгости (но на практике ближе кany, так как разрешает любые операции);never— невыполнимый тип (например, для функции, всегда вызывающейerror);?T(илиT?) — сокращение дляT | nil, nilable-тип;
- Union-типы:
string | number,Part | Model | nil; - Generics (универсальные типы), введённые в 2023 году:
type LinkedList<T> = { value: T, next: LinkedList<T>? }
function map<T, U>(list: LinkedList<T>, f: (T) -> U): LinkedList<U>
if list == nil then return nil end
return { value = f(list.value), next = map(list.next, f) }
end
Генерики поддерживаются как на уровне type, так и на уровне функций. Важно: инференс (выведение типов) в Luau мощный: если параметры функции типизированы, а возвращаемое значение не указано явно, анализатор попытается его вывести. Однако для рекурсивных функций и сложных полиморфных случаев явная аннотация возвращаемого типа обязательна.
2.3. Интеграция с Roblox Instance API
Одна из самых значимых особенностей Luau — глубокая интеграция со структурой объектов Roblox. В платформе все сущности (персонажи, части мира, скрипты) представлены как экземпляры класса Instance, иерархия которого задаётся статически (например, Part : BasePart : Instance, Script : LuaSourceContainer : Instance, Player : Instance). Luau знает эту иерархию и позволяет использовать имена классов как типы:
local part: Part = Instance.new("Part")
local player: Player = game.Players:GetPlayers()[1]
Более того, при доступе к свойствам и методам через точку или двоеточие IDE (через Language Server) предоставляет точную подсказку:
part.Size = Vector3.new(5, 5, 5) -- ok
part.Health = 100 -- ошибка: Part не имеет свойства Health
Это достигается за счёт встроенного type definition для всей Roblox API, поддерживаемого командой Roblox и обновляемого с каждой версией движка. Таким образом, типизация охватывает не только «чистый» Lua-код, но и взаимодействие с платформой.
3. Совместимость с Lua 5.1 и отклонения
Luau сохраняет почти полную обратную совместимость с Lua 5.1 на уровне синтаксиса и семантики выполнения. Можно скопировать любой валидный Lua 5.1-скрипт — он будет работать в Roblox без изменений (если не использует ограниченные API, например, os.execute или io.*).
Однако есть важные оговорки:
- Нет поддержки
gotoи меток. Это сознательное решение, вызванное сложностью интеграции с типовой системой и JIT-компиляцией. - Ограниченная поддержка замыканий в
for-циклах. В Lua 5.1 переменная цикла захватывается по ссылке, что приводит к известной ошибке:В Luau это поведение изменено: переменная цикла захватывается по значению, как в Lua 5.2+. Это не нарушает совместимость на уровне валидности кода, но изменяет семантику.local funcs = {}
for i = 1, 3 do
funcs[i] = function() print(i) end
end
funcs[1]() -- печатает 4 - Отсутствие
debug.*API, кроме ограниченных функций вродеdebug.traceback. Безопасность платформы требует изоляции скриптов. - Другие запрещённые глобальные:
loadstring,dofile,package.*,os.*,io.*. Доступны толькоprint,warn,assert,error,math.*,string.*,table.*,utf8.*и некоторые другие. - Синтаксические расширения (типы,
type,export type) не являются валидным Lua 5.1. Это означает, что код с аннотациями не будет работать в стандартной Lua-среде без транспиляции. Однако транспилятор Luau (в составе компилятора) автоматически удаляет аннотации перед генерацией байткода — в рантайме их нет.
4. Производительность: от интерпретатора к JIT-компиляции в условиях изоляции
Любая скриптовая платформа, ориентированная на реальное время (real-time interactivity), сталкивается с фундаментальным противоречием: гибкость динамического языка vs. предсказуемая производительность. Ранние версии Roblox использовали интерпретатор LuaJIT 2.0, но его интеграция оказалась небезопасной и несовместимой с архитектурой sandbox’а. Поэтому Roblox разработал собственный виртуальный процессор (Luau VM) с последующим внедрением JIT-компилятора, оптимизированного под специфику платформы.
4.1. Архитектура Luau VM
Luau VM — это стек-машина с 32-битным байткодом, построенная по образцу Lua 5.1 VM, но с рядом изменений:
- расширенный набор инструкций — более 80 против 38 в Lua 5.1, включая специализированные для работы с таблицами и вызовами методов (
GETIMPORT,FASTCALLn); - поддержка «быстрых путей» для частых операций: например, прямой доступ к полям через смещения, если структура таблицы стабильна;
- inline-кэширование вызовов функций и метаметодов.
Байткод генерируется статическим компилятором Luau, который включает в себя не только лексический и синтаксический анализ, но и анализ потока типов (type flow analysis). Это позволяет, например, устранять избыточные проверки nil или объединять последовательные операции на этапе компиляции.
4.2. JIT-компиляция: адаптивная оптимизация под нагрузку
JIT в Luau активируется автоматически для «горячих» функций — тех, которые вызываются часто (порог настраивается, по умолчанию ~100 вызовов). Он транслирует байткод в нативный x64-код (на Windows/macOS) или ARM64 (на iOS/Android) с учётом профиля выполнения:
- Инлайнинг: маленькие функции встраиваются непосредственно в вызывающий код.
- Деоптимизация по demand: если во время выполнения встречается значение другого типа (например, ожидался
number, пришёлstring), JIT-код деоптимизируется и возвращается к интерпретируемому режиму для этой ветви — без краха приложения. Это критически важно для gradual typing. - Оптимизация замыканий: переменные, захваченные в замыкании, могут быть размещены в регистрах, а не в куче, если анализ показывает, что их жизненный цикл ограничен.
- Устранение границ проверок (
bounds check elimination) для индексов таблиц при стабильных шаблонах доступа.
Важно: JIT работает только на стороне клиента. Серверные скрипты (в облаке Roblox Cloud) исполняются в режиме интерпретации с агрессивной оптимизацией байткода, но без генерации нативного кода — из соображений детерминизма и воспроизводимости (все серверы должны выдавать одинаковый результат при одинаковом входе).
Это разделение — не недостаток, а сознательный дизайн: клиент может жертвовать детерминизмом ради плавности анимации и отклика, тогда как сервер обязан обеспечивать согласованность состояния мира для всех участников.
5. Архитектура выполнения в Roblox: клиент, сервер и их взаимодействие
В отличие от классических приложений, где клиент и сервер — разные процессы с чётким разделением ответственности, в Roblox граница проходит через один и тот же скрипт. Разработчик управляет не процессами, а контекстом выполнения, задаваемым типом скрипта:
| Тип скрипта | Контекст | Описание |
|---|---|---|
Script | Сервер | Выполняется только на сервере. Имеет полный доступ к экземплярам, может изменять состояние мира для всех. |
LocalScript | Клиент | Выполняется только на клиенте конкретного игрока. Имеет доступ к PlayerGui, может читать (но не писать) большинство свойств серверных объектов. |
ModuleScript | Любой | Контейнер для кода, импортируемого (require()) другими скриптами. Может содержать и клиентскую, и серверную логику — в зависимости от того, кто его вызывает. |
5.1. Модель данных: иерархия Instance
Вся виртуальная вселенная Roblox представлена в виде древовидной структуры объектов, наследующих от Instance. Корень — game, псевдоним для DataModel. Наиболее важные ветви:
game.Workspace— физическое пространство мира: части, модели, персонажи. Доступен и клиенту, и серверу.game.Players— коллекция всех игроков. Сервер может изменять состав (например, банить), клиент видит только себя и других (только чтение).game.ReplicatedStorage— область, синхронизируемая от сервера к клиентам. Используется для хранения общих ресурсов: модулей, ассетов, шаблонов персонажей. Изменения здесь реплицируются автоматически.game.ReplicatedFirst— аналогично, но репликация происходит до загрузки игрока (для критически важных ресурсов).game.ServerScriptService,game.ServerStorage,game.StarterPlayer— серверные/клиентские контейнеры, недоступные на противоположной стороне.
Репликация — ключевой механизм синхронизации. Не все изменения транслируются: только свойства, помеченные как Replicated (внутренний флаг API). Например, изменение Part.Position реплицируется, а изменение локальной переменной в скрипте — нет.
5.2. Событийная модель: RemoteEvent и RemoteFunction
Прямой вызов кода на другой стороне невозможен. Взаимодействие осуществляется через события:
-
RemoteEvent: односторонняя отправка данных (fire-and-forget).-- Server
local event = Instance.new("RemoteEvent")
event.Parent = ReplicatedStorage
event.OnServerEvent:Connect(function(player, data)
print(player.Name .. " sent: " .. data)
end)
-- Client
local event = ReplicatedStorage:WaitForChild("RemoteEvent")
event:FireServer("Hello from client!") -
RemoteFunction: двухсторонний вызов с возвратом значения (синхронный на клиенте, асинхронный на сервере).-- Server
local func = Instance.new("RemoteFunction")
func.Parent = ReplicatedStorage
func.OnServerInvoke = function(player, x, y)
return x * y
end
-- Client
local result = func:InvokeServer(6, 7) -- result = 42
Важно: RemoteFunction:InvokeServer() блокирует клиентский поток до ответа. Злоупотребление приводит к «подвисанию» клиента. Рекомендуется использовать RemoteEvent + коллбэки для асинхронных сценариев.
6. Практический пример: визуальный эффект при прыжке игрока
Рассмотрим реализацию эффекта частиц, появляющегося под ногами игрока в момент прыжка. Это требует:
- обнаружения события прыжка (клиент);
- проверки валидности (сервер);
- создания частиц (клиент, но по команде сервера или с его разрешения).
6.1. Клиентская часть (LocalScript в StarterPlayerScripts)
-- JumpEffectClient.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
-- Подготавливаем шаблон эффекта (загружен заранее в ReplicatedStorage)
local jumpEffectTemplate = ReplicatedStorage:WaitForChild("JumpEffect"):Clone()
-- Событие для запроса эффекта у сервера
local requestJumpEffect = ReplicatedStorage:WaitForChild("RequestJumpEffect")
humanoid.StateChanged:Connect(function(oldState, newState)
if newState == Enum.HumanoidStateType.Jumping then
-- Отправляем запрос серверу — не создаём эффект напрямую!
requestJumpEffect:FireServer()
end
end)
-- Сервер отвечает через другой RemoteEvent
local playJumpEffect = ReplicatedStorage:WaitForChild("PlayJumpEffect")
playJumpEffect.OnClientEvent:Connect(function(position: Vector3)
local effect = jumpEffectTemplate:Clone()
effect.Position = position
effect.Parent = workspace
effect:Emit(10)
task.delay(2, function() effect:Destroy() end)
end)
6.2. Серверная часть (Script в ServerScriptService)
-- JumpEffectServer.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local requestJumpEffect = Instance.new("RemoteEvent")
requestJumpEffect.Name = "RequestJumpEffect"
requestJumpEffect.Parent = ReplicatedStorage
local playJumpEffect = Instance.new("RemoteEvent")
playJumpEffect.Name = "PlayJumpEffect"
playJumpEffect.Parent = ReplicatedStorage
requestJumpEffect.OnServerEvent:Connect(function(player: Player)
-- Валидация: жив ли персонаж? Делал ли он прыжок легально?
local character = player.Character
if not character then return end
local humanoid = character:FindFirstChild("Humanoid")
if not humanoid or humanoid:GetState() ~= Enum.HumanoidStateType.Jumping then
warn(player.Name .. " attempted invalid jump effect")
return
end
-- Определяем позицию: обычно — под центром масс
local rootPart = character:FindFirstChild("HumanoidRootPart")
if not rootPart then return end
local position = rootPart.Position - Vector3.new(0, 2, 0) -- чуть ниже ног
playJumpEffect:FireClient(player, position) -- только этому игроку
end)
6.3. Архитектурные замечания
- Безопасность: клиент не создаёт эффект напрямую — только по разрешению сервера. Это предотвращает читерство (например, спам частицами для маскировки).
- Эффективность: шаблон
JumpEffect—ParticleEmitter, загруженный один раз вReplicatedStorage. Его клонирование дешевле, чем создание черезInstance.new("ParticleEmitter"). - Изоляция: каждый клиент создаёт эффект локально — другие игроки не получают данные о частицах (если не требуется синхронизация, например, для взрывов), что экономит трафик.
7. Инструментарий разработки: от print() до Language Server Protocol
7.1. Отладка в среде
Roblox Studio предоставляет:
- Output — лог
print(),warn(),error(). - Developer Console (Ctrl+F9) — во время игры позволяет:
- просматривать стек вызовов;
- устанавливать точки останова (breakpoints) в скриптах;
- инспектировать переменные в текущем фрейме;
- выполнять произвольный код в контексте (REPL-подобный режим).
Однако print() остаётся основным инструментом из-за простоты. Для структурированного логгирования рекомендуется обёртка:
local Logger = {}
Logger.Level = "INFO" -- DEBUG, INFO, WARN, ERROR
function Logger.debug(msg: string)
if Logger.Level == "DEBUG" then print("[DEBUG] " .. msg) end
end
7.2. Luau LSP и статический анализ
В 2022 году Roblox выпустил Luau Language Server — реализацию Language Server Protocol (LSP), интегрируемую в VS Code, Sublime Text, Neovim и др. Он предоставляет:
- автодополнение с учётом типов и Roblox API;
- переход к определению (
Go to Definition); - поиск всех ссылок (
Find All References); - переименование (
Rename Symbol); - inline-проверку типов в реальном времени;
- предупреждения о неиспользуемых переменных, неявных
any, потенциальныхnil-разыменованиях.
Анализатор также поддерживает strict mode (включается через --!strict в начале файла), который:
- запрещает необъявленные глобальные переменные;
- требует аннотации для
function,local, параметров; - интерпретирует
nilкак отдельный тип (без автоматического объединения).
Это приближает Luau к «промышленному» уровню TypeScript.
8. Практика: создание первой игры — «Сборщик монет»
Для закрепления теории рассмотрим пошаговое создание мини-игры, демонстрирующей ключевые концепции:
- Цель: игрок управляет персонажем, собирает монеты (
PartсTouchInterest), счёт отображается на экране. - Архитектура:
- сервер: спавн монет, хранение счёта, валидация сбора;
- клиент: отображение GUI, анимация сбора;
- модуль: общая логика (например,
CoinSpawner).
8.1. Создание монеты
- В
ReplicatedStorageсоздаёмPart→CoinTemplate:BrickColor = BrickColor.new("Bright yellow")Shape = Enum.PartType.Ball,Size = Vector3.new(1,1,1)- Добавляем
ClickDetectorили используемTouched(осторожно:Touchedсрабатывает при любом контакте, включая стены).
8.2. Серверный скрипт спавна
-- ServerScriptService/CoinManager.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local COIN_TEMPLATE = ReplicatedStorage:WaitForChild("CoinTemplate")
local COIN_SPAWN_POINTS = ServerStorage:WaitForChild("CoinSpawns") -- Folder с Part'ами
local coinCount = 0
local coins = {}
local function spawnCoin(at: Vector3)
local coin = COIN_TEMPLATE:Clone()
coin.Position = at
coin.Anchored = true
coin.Parent = workspace
local touchedConn
touchedConn = coin.Touched:Connect(function(hit)
local character = hit:FindFirstAncestorOfClass("Model")
if not character then return end
local player = game.Players:GetPlayerFromCharacter(character)
if not player then return end
-- Валидация: не слишком ли часто?
if os.clock() - (coins[coin] or 0) < 0.5 then return end
coinCount += 1
coins[coin] = os.clock()
touchedConn:Disconnect()
coin:Destroy()
-- Уведомляем клиента
game.ReplicatedStorage.CoinCollected:FireClient(player, coinCount)
end)
coins[coin] = 0
end
-- Спавним по точкам
for _, point in COIN_SPAWN_POINTS:GetChildren() do
if point:IsA("BasePart") then
spawnCoin(point.Position)
end
end
8.3. Клиентский GUI
- В
StarterGuiсоздаёмScreenGui→TextLabel(CoinCounter). LocalScriptвStarterPlayerScripts:
-- LocalScript/CoinUI.luau
local Players = game:GetService("Players")
local player = Players.LocalPlayer
local coinCounter = player:WaitForChild("PlayerGui"):WaitForChild("CoinCounter")
game.ReplicatedStorage.CoinCollected.OnClientEvent:Connect(function(count: number)
coinCounter.Text = "Монет: " .. tostring(count)
end)
8.4. Обучение через ограничения
Эта простая игра уже включает:
- работу с иерархией
Instance; - событийную модель (
Touched); - клиент-серверное взаимодействие;
- валидацию входных данных;
- репликацию состояния через
RemoteEvent; - работу с GUI.
Дальнейшее развитие: добавить таймер, уровни, сохранение прогресса через DataStoreService, звуки, анимации.
9. Продвинутая типизация: инференс, вариативность и ограничения generics
9.1. Механизм inference и его границы
Luau использует flow-sensitive type inference (чувствительный к потоку управления вывод типов), что означает: тип переменной может меняться в зависимости от ветвления.
local x = math.random() > 0.5 and "hello" or 123
-- x: string | number
if typeof(x) == "string" then
print(x:upper()) -- ok: в этой ветке x: string
else
print(x + 1) -- ok: x: number
end
Однако inference ограничен:
-
Нет полиморфного рекурсивного вывода. Для рекурсивных функций аннотация возвращаемого типа обязательна:
-- ОШИБКА: Cannot infer type for recursive function
function factorial(n)
if n <= 1 then return 1 end
return n * factorial(n - 1)
end
-- Правильно:
function factorial(n: number): number
if n <= 1 then return 1 end
return n * factorial(n - 1)
end -
Циклы не сужают типы. В отличие от TypeScript, Luau не отслеживает изменения в циклах:
local items: {string | number} = {"a", 1, "b", 2}
for _, v in items do
if typeof(v) == "string" then
-- v здесь всё ещё string | number
-- приходится использовать утверждение:
local s = v :: string
print(s:upper())
end
end -
Утверждения типов (
::) — последнее средство. Они отключают проверку, но не генерируют проверок в рантайме:local x: any = "text"
local len = (x :: string).len -- ошибка: поле len нет у string
-- Анализатор не проверяет содержимое после `::`
9.2. Generics: параметрический полиморфизм в условиях ограниченной выразительности
Luau поддерживает generics с 2023 года, но с оговорками. Синтаксис:
type Box<T> = { value: T }
function createBox<T>(value: T): Box<T>
return { value = value }
end
local strBox = createBox("hello") -- Box<string>
local numBox = createBox(42) -- Box<number>
Ограничения:
-
Нет bounded generics (ограничений типов). Нельзя написать:
-- НЕ РАБОТАЕТ
function add<T: number | string>(a: T, b: T): TВместо этого используют union-типы в параметрах:
function add(a: number | string, b: number | string): number | string
if typeof(a) == "number" and typeof(b) == "number" then
return a + b
elseif typeof(a) == "string" and typeof(b) == "string" then
return a .. b
else
error("Invalid types")
end
end -
Нет generic-модулей верхнего уровня.
typeс generics должны быть определены внутри scope (функция, таблица). Глобальные generic-типы ограничены по сложности. -
Инференс generics в вызовах — частичный. Если хотя бы один параметр аннотирован, тип выводится:
function map<T, U>(list: {T}, f: (T) -> U): {U} ... end
local strings = map({1,2,3}, function(x) return tostring(x) end) -- {string} -
Нет variance-модификации (covariance/contravariance). Luau рассматривает
Box<Part>иBox<Instance>как несовместимые типы, даже при наследованииPart : Instance. Это безопасно, но требует ручного кастинга:local partBox: Box<Part> = createBox(Instance.new("Part"))
local instBox: Box<Instance> = partBox :: any -- прямое присваивание невозможно
Эти ограничения — компромисс между выразительностью и производительностью анализа. В крупных проектах это компенсируется использованием фабрик и интерфейсов.
10. Паттерны проектирования в экосистеме Luau
Roblox-разработка породила собственные идиомы, адаптированные под ограничения платформы: отсутствие true-множественного наследования, необходимость изоляции клиент/сервер, и высокая стоимость создания Instance.
10.1. Signal: замена RBXScriptSignal
Встроенная система событий Roblox (BindableEvent, RemoteEvent) неудобна для внутренней логики: они — Instance, их создание дорого, и они не поддерживают типизацию параметров.
Поэтому повсеместно используется паттерн Signal — pure-Lua реализация события:
-- ModuleScript: Signal.luau
export type Connection = { Disconnect: () -> () }
export type Signal<T...> = {
Connect: (self: Signal<T...>, callback: (T...) -> ()) -> Connection,
Fire: (self: Signal<T...>, T...) -> (),
Once: (self: Signal<T...>, callback: (T...) -> ()) -> Connection,
}
type SignalImpl<T...> = {
_callbacks: { (T...) -> () },
_onceCallbacks: { (T...) -> () },
}
local Signal = {}
Signal.__index = Signal
function Signal.new<T...>(): Signal<T...>
return setmetatable({
_callbacks = {},
_onceCallbacks = {},
} :: SignalImpl<T...>, Signal)
end
function Signal:Connect<T...>(callback: (T...) -> ()): Connection
table.insert(self._callbacks, callback)
return {
Disconnect = function()
local index = table.find(self._callbacks, callback)
if index then table.remove(self._callbacks, index) end
end
}
end
function Signal:Once<T...>(callback: (T...) -> ()): Connection
table.insert(self._onceCallbacks, callback)
return {
Disconnect = function()
local index = table.find(self._onceCallbacks, callback)
if index then table.remove(self._onceCallbacks, index) end
end
}
end
function Signal:Fire<T...>(...: T...)
for _, cb in self._callbacks do
task.spawn(cb, ...) -- асинхронный вызов во избежание блокировки
end
for _, cb in self._onceCallbacks do
task.spawn(cb, ...)
end
self._onceCallbacks = {}
end
return Signal
Преимущества:
- 100% Lua, нет
Instance; - полная типизация параметров и возвращаемых значений;
- поддержка variadic generics;
- совместимость с Luau LSP (автодополнение в
Connectработает корректно).
Использование:
local Signal = require(script.Parent.Signal)
local playerDied = Signal.new<Player, number>() -- Player, время смерти (сек)
playerDied:Connect(function(player, time)
print(player.Name .. " died at " .. time)
end)
playerDied:Fire(somePlayer, os.clock())
10.2. Service Locator и модульная архитектура
Крупные проекты (>100 скриптов) структурируются по принципу сервисов — одиночных (Singleton) модулей, управляемых глобальным локатором.
Структура:
src/
├── Services/
│ ├── PlayerService.luau
│ ├── EconomyService.luau
│ └── ...
├── Shared/
│ └── Types.luau
└── init.luau -- точка входа
init.luau (в ServerScriptService и StarterPlayerScripts):
-- init.luau
local Services = {}
-- Ленивая инициализация
function Services.GetService<T>(name: string): T
if not rawget(Services, name) then
local module = require(script.Parent.Services[name])
Services[name] = module.new()
if Services[name].Init then Services[name]:Init() end
end
return Services[name] :: T
end
-- Экспорт в глобальную область (только для внутреннего использования)
_G.Services = Services
-- Запуск
Services.GetService("PlayerService")
PlayerService.luau:
-- Services/PlayerService.luau
type PlayerData = { coins: number, level: number }
local PlayerService = {}
PlayerService.__index = PlayerService
function PlayerService.new()
local self = setmetatable({
_players = {} :: { [Player]: PlayerData },
}, PlayerService)
return self
end
function PlayerService:Init()
game.Players.PlayerAdded:Connect(function(player)
self._players[player] = { coins = 0, level = 1 }
-- Загрузка из DataStore — отдельный сервис
end)
game.Players.PlayerRemoving:Connect(function(player)
self._players[player] = nil
end)
end
function PlayerService:GetCoins(player: Player): number
local data = self._players[player]
return data and data.coins or 0
end
function PlayerService:AddCoins(player: Player, amount: number)
local data = self._players[player]
if data then
data.coins += amount
-- Оповещение GUI через RemoteEvent или Signal
end
end
return PlayerService
Преимущества:
- Чёткое разделение ответственности;
- Легко mock’ать в тестах;
- Возможность lazy-загрузки.
10.3. Promise-подобные конструкции (без async/await)
Luau не имеет async/await, но поддерживает корутины через task.spawn, task.delay, task.wait. Для композиции асинхронных операций используется паттерн Promise, реализованный в стиле Lua:
-- ModuleScript: Promise.luau
export type Promise<T> = {
andThen: <U>(self: Promise<T>, (T) -> U | Promise<U>) -> Promise<U>,
catch: (self: Promise<T>, (string) -> T) -> Promise<T>,
await: (self: Promise<T>) -> T,
}
local Promise = {}
Promise.__index = Promise
function Promise.new<T>(executor: (resolve: (T) -> (), reject: (string) -> ()) -> ())
local self = setmetatable({
_state = "pending" :: "pending" | "fulfilled" | "rejected",
_value = nil :: T?,
_reason = nil :: string?,
_onFulfilled = {} :: { (T) -> () },
_onRejected = {} :: { (string) -> () },
}, Promise)
local function resolve(value: T)
if self._state ~= "pending" then return end
self._state = "fulfilled"
self._value = value
for _, cb in self._onFulfilled do task.spawn(cb, value) end
end
local function reject(reason: string)
if self._state ~= "pending" then return end
self._state = "rejected"
self._reason = reason
for _, cb in self._onRejected do task.spawn(cb, reason) end
end
task.spawn(executor, resolve, reject)
return self
end
-- Реализация andThen, catch, await — опущена для краткости, но доступна в open-source библиотеках (например, NevermoreEngine)
-- Использование:
local p = Promise.new(function(resolve, reject)
task.delay(1, function()
resolve("done")
end)
end)
p:andThen(function(result)
print(result) -- "done"
return result .. " and processed"
end):andThen(print) -- "done and processed"
Важно: такие реализации не эквивалентны JS-Promise по семантике (не имеют микрозадач), но решают задачу управления асинхронным потоком.
11. Интеграция с внешними инструментами: разработка вне Roblox Studio
Roblox Studio — удобная среда, но непригодна для командной работы, CI/CD и тестирования. Поэтому развиты инструменты для внешней разработки.
11.1. Rojo: синхронизация файловой системы и DataModel
Rojo — open-source утилита, которая:
- монтирует иерархию
InstanceRoblox как файловую систему; - позволяет писать скрипты в
.luau-файлах, а не в окне Studio; - поддерживает сборку проектов (
default.project.json); - интегрируется с Git, CI, редакторами.
Пример default.project.json:
{
"name": "MyGame",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$className": "ServerScriptService",
"main.lua": {
"$path": "src/server/main.luau"
}
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Modules": {
"$path": "src/shared"
}
}
}
}
Rojo запускается в watch-режиме: любое изменение в src/ мгновенно отражается в запущенном Roblox Studio.
11.2. Selene: линтер для Luau
Selene — официальный линтер от Roblox, заменяющий устаревший StyLua.
Конфигурация (selene.toml):
std = "roblox+testez"
[config]
unused_variable = "allow" # в учебных проектах — allow, в продакшене — "deny"
global_usage = "allow" # для _G, game и т.д.
[rules]
roblox_unsafe_call = "error" # запрет os.time(), warn()
unused_variable = "warn"
Selene понимает:
- Roblox API;
- аннотации Luau;
- особенности
taskиcoroutine; - common pitfalls (например, захват переменной цикла).
11.3. Tarmac и TestEZ: тестирование
- Tarmac — фреймворк для unit- и интеграционного тестирования Luau-кода, запускаемый в headless-режиме Roblox.
- TestEZ — BDD-фреймворк (аналог Jasmine), интегрируемый с Tarmac.
Пример теста:
-- tests/PlayerService.spec.luau
local TestEZ = require(game.ReplicatedStorage.TestEZ)
local PlayerService = require(game.ReplicatedStorage.Modules.PlayerService)
TestEZ.describe("PlayerService", function()
TestEZ.it("starts with 0 coins", function()
local service = PlayerService.new()
local mockPlayer = { Name = "Test" } :: any
service:Init()
service._players[mockPlayer] = { coins = 0, level = 1 }
expect(service:GetCoins(mockPlayer)).to.equal(0)
end)
end)
Запуск через CLI:
rojo build project.project.json -o build.rbxlx
tarmac run build.rbxlx --test-filter="PlayerService"
11.4. CI/CD: сборка и деплой
Типичный pipeline в GitHub Actions:
name: Build & Test
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Roblox/setup-foreman@v2
- run: foreman install rojo selene tarmac
- run: rojo build default.project.json -o game.rbxlx
- run: selene src/
- run: tarmac run game.rbxlx --test-path=tests/
- run: rbx-cli publish --cookie "$ROBLOSECURITY" --place-id 123456789 game.rbxlx
env:
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}
Это позволяет:
- гарантировать отсутствие regression’ов;
- форсировать стандарты кода;
- автоматически деплоить в Roblox после успешного merge в
main.